Skip to content

Add uv fallback for inline script environments (PEP 723 PR 6/16) - #1696

Merged
Stella Huang (StellaHuang95) merged 3 commits into
microsoft:mainfrom
StellaHuang95:pep723-pr6-uv-fallback
Aug 13, 2026
Merged

Add uv fallback for inline script environments (PEP 723 PR 6/16)#1696
Stella Huang (StellaHuang95) merged 3 commits into
microsoft:mainfrom
StellaHuang95:pep723-pr6-uv-fallback

Conversation

@StellaHuang95

@StellaHuang95 Stella Huang (StellaHuang95) commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Part of #1602 (PEP 723 inline script env support). Design doc: #1601.

This replaces the earlier closed draft #1652 with the finalized implementation rebased on main.

Roadmap context

This is PR 6 of 16 in the PEP 723 inline-script roadmap. It extends the PR 5 create() happy path with the missing-compatible-interpreter fallback.

Phase 2: Manager PR Status
PR 4: InlineScriptEnvManager skeleton merged (#1610)
PR 5a: generic env-creation utilities merged (#1651)
PR 5b: inline-script cache + interpreter utilities merged (#1655)
PR 5c: create() happy path merged (#1656)
PR 6: create() uv-install fallback this PR
PR 7: persistence (get / set + Memento) separate follow-up

Why this PR

PR 5 can create or reuse an inline-script environment when an installed base interpreter already satisfies the script's requires-python. It deliberately stops when no compatible interpreter exists.

This PR adds the consent-gated fallback for that case:

  1. Re-check installed interpreters after entering a narrow fallback queue.
  2. Select a safe Python version satisfying the complete PEP 440 requirement.
  3. Ask the user before installing uv or Python.
  4. Refresh environment discovery after installation.
  5. If discovery is stale or unavailable, resolve and validate uv's returned executable directly.
  6. Continue through the existing PR 5 cache create/reuse path.

What this PR does

Adds the inline-script fallback to InlineScriptEnvManager.create()

  • Keeps the installed-interpreter path unchanged and only enters the fallback when no compatible base exists.
  • Skips installation prompts for quick-create/noninteractive calls.
  • Serializes only fallback installations, then re-checks discovery before prompting so concurrent compatible requests reuse one installation.
  • Retains successfully direct-resolved uv interpreters so queued requests can reuse them even while discovery remains stale.

Selects a safe uv target from requires-python

  • Uses simple safe selectors directly, such as >=3.133.13 and ==3.13.13.13.1.
  • Queries uv's advertised versions for bounded or exclusion-heavy constraints.
  • Restricts catalog candidates to default CPython 3 builds satisfying the complete specifier.
  • Handles exclusions such as >=3.13.2,!=3.13.2 without installing the excluded floor.
  • Uses an advertised release for bounded ranges rather than fabricating a potentially unavailable patch-zero release.
  • Applies full PEP 440 prerelease semantics consistently across discovery, catalog selection, direct resolution, and cache validation.
  • Normalizes accepted prerelease aliases (for example, c1rc1) before passing a version to uv.

Extends the uv installer's consent flow

  • Adds an inline-script-specific prompt that shows both the script requirement and selected Python version.
  • Sanitizes and caps script-controlled prompt details.
  • Validates install selectors before forwarding them to uv.
  • If catalog lookup is required and uv is missing, asks for consent to install uv first.
  • Re-checks whether a newly installed uv is usable by the current extension host and surfaces the existing restart-required message when needed.

Handles stale discovery after installation

  • Refreshes environment discovery after uv installs Python.
  • Treats refresh/discovery failures as recoverable.
  • Resolves the executable returned by uv directly, verifies that it satisfies requires-python, and canonicalizes its path before creating the cached environment.

Examples

requires-python Fallback behavior
>=3.13 Request uv's 3.13 selector
==3.13.1 Request exactly 3.13.1 without requiring a catalog lookup
>=3.11,<3.12 Choose an advertised compatible 3.11.x release
>=3.13.2,!=3.13.2 Skip 3.13.2 and choose a compatible advertised release
>=3.15.0a1,<3.16 Permit an explicitly requested prerelease
>=3.14,<3.16 Do not select a prerelease implicitly

Safety and concurrency

  • No uv or Python installation occurs without explicit user consent.
  • Script-derived values cannot inject arbitrary uv arguments.
  • Declined, cancelled, or failed installations do not mutate the script-environment cache.
  • The fallback queue does not globally serialize environment selection or normal cache creation.
  • Existing cross-process cache locking and rollback behavior from PR 5 remains unchanged.

Tests

Coverage includes:

  • simple, exact, bounded, exclusion, and prerelease requirements;
  • uv catalog filtering and consented uv bootstrap;
  • declined and failed installations;
  • refresh and discovery failures with direct resolution;
  • simultaneous same- and different-constraint requests;
  • quick-create prompt suppression;
  • strict post-install and cache compatibility checks;
  • prompt sanitization and install-selector validation.

npm run compile-tests, npm run lint, the full unit suite, and the focused inline-script/uv suites are clean.

User impact

No default-path user impact yet. This completes an internal Phase 2 manager capability. Automatic routing and user-facing entry points arrive in later roadmap PRs.

When those entry points are wired, users whose scripts require an unavailable Python will be able to approve installing a compatible interpreter rather than having environment creation stop.

@StellaHuang95

Copy link
Copy Markdown
Contributor Author

🔒 Automated review in progress — Stella Huang (@StellaHuang95) is auto-reviewing this PR.

Comment thread src/managers/builtin/inlineScript/envManager.ts
Comment thread src/managers/builtin/inlineScript/envManager.ts
Comment thread src/managers/builtin/inlineScript/envManager.ts Outdated
Comment thread src/managers/builtin/inlineScript/envManager.ts
Comment thread src/managers/builtin/inlineScript/envManager.ts
Comment thread src/managers/builtin/inlineScript/envManager.ts
Comment thread src/managers/builtin/inlineScript/envManager.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:)

@StellaHuang95
Stella Huang (StellaHuang95) force-pushed the pep723-pr6-uv-fallback branch 2 times, most recently from 3521a00 to af8592c Compare August 12, 2026 17:08

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this fallback. I found two related gaps that need addressing:

  1. In InlineScriptEnvManager.create, fallback interpreter selection and installation happen before the per-script entry is added to pendingCreations. Concurrent create() calls for the same script can therefore both display prompts and launch competing uv/Python installations. Please move the fallback work inside the deduplicated operation.

  2. The new uv-version-lookup installation path is not effectively exercised. The manager tests stub ensureUvForInlineScriptVersionLookup() to return true, while the installer tests cover only dismissing its prompt. Please add coverage for accepted installation success, installation failure, uv remaining unavailable/restart-required, cancellation, and concurrent same-script creation.

Add consent-gated uv installation when no installed interpreter satisfies a script. Coalesce matching installs, skip prompts for quick create, and directly resolve a successful installation when discovery is stale or unavailable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1a9f6ba1-9bd3-4664-bc25-a0d34d7a2e91
@StellaHuang95

Copy link
Copy Markdown
Contributor Author

Thanks for adding this fallback. I found two related gaps that need addressing:

  1. In InlineScriptEnvManager.create, fallback interpreter selection and installation happen before the per-script entry is added to pendingCreations. Concurrent create() calls for the same script can therefore both display prompts and launch competing uv/Python installations. Please move the fallback work inside the deduplicated operation.
  2. The new uv-version-lookup installation path is not effectively exercised. The manager tests stub ensureUvForInlineScriptVersionLookup() to return true, while the installer tests cover only dismissing its prompt. Please add coverage for accepted installation success, installation failure, uv remaining unavailable/restart-required, cancellation, and concurrent same-script creation.

Thanks, both gaps are addressed.

  1. I added request-level coalescing before interpreter selection and uv fallback. Identical concurrent requests now share the entire setup result, including successful installation, decline, and failure, so the same script cannot show repeated prompts or retry the same installation. The key includes the normalized script path,  requires-python , effective package set, and quick-create mode, so requests with different requirements/options are not incorrectly combined. The existing cache-key coalescing remains in place for equivalent final environments across different scripts.
  2. I added direct coverage for the uv version-lookup bootstrap path: accepted installation success, installation failure, uv remaining unavailable with restart guidance, and cancelled installation. I also added concurrent same-script tests for both successful and declined fallback.

Coalesce identical inline-script setup requests before interpreter installation and cover uv bootstrap success and failure paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 1a9f6ba1-9bd3-4664-bc25-a0d34d7a2e91
private readonly pendingSetups = new Map<string, Promise<PythonEnvironment | undefined>>();
private readonly pendingCreations = new Map<string, Promise<PythonEnvironment | undefined>>();
private readonly directlyResolvedBaseInterpreters = new Map<string, PythonEnvironment>();
private baseInterpreterInstallationQueue: Promise<void> = Promise.resolve();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this used for?

@StellaHuang95
Stella Huang (StellaHuang95) merged commit f5fb6c3 into microsoft:main Aug 13, 2026
44 checks passed
Stella Huang (StellaHuang95) added a commit that referenced this pull request Aug 14, 2026
> Part of #1602 (PEP 723 inline script env support). Design doc: #1601.
>
> This replaces the earlier closed draft #1653 with the finalized
implementation rebased on `main`.

### Roadmap context

This is **PR 7 of 16** in the PEP 723 inline-script roadmap. It adds
durable per-script environment associations to the internal manager.

| Phase 2: Manager | PR | Status |
|---|---|---|
| | PR 4: `InlineScriptEnvManager` skeleton | merged (#1610) |
| | PR 5a: generic env-creation utilities | merged (#1651) |
| | PR 5b: inline-script cache + interpreter utilities | merged (#1655)
|
| | PR 5c: `create()` happy path | merged (#1656) |
| | PR 6: `create()` uv-install fallback | open (#1696) |
| | **PR 7: persistence (`get` / `set` + Memento)** | **this PR** |
| | PR 8: activation-time discovery | follow-up |
| | PR 9: route PEP 723 scripts to the inline manager | follow-up |

### Why this PR

PR 5 can build or reuse an inline-script environment, but the manager
does not remember that the resulting environment belongs to a particular
script. After an extension-host restart, the in-memory association is
gone.

This PR implements the persistence portion of Q4 in the design:

- maintain an independent environment association for each script;
- persist script-to-environment executable paths in workspace Memento;
- lazily and safely rehydrate those associations;
- re-check current `requires-python` metadata before returning an
environment;
- report changes so the central environment API can update its
last-known state.

### What this PR does

**Implements per-script `set()`**

- Accepts one or more local `file:` URIs and rejects invalid or mixed
scopes atomically.
- Validates that selected environments are owned inline-script cache
entries.
- Persists a normalized script path → environment executable path
mapping under a dedicated Memento key.
- Supports assigning and unassigning individual scripts or batches.
- Updates in-memory state and emits `onDidChangeEnvironment` only for
effective changes.
- Leaves the existing `create()` behavior separate: creation alone does
not implicitly establish a persisted association.

**Implements per-script `get()`**

- Reads current PEP 723 metadata before returning an association.
- Keeps unreadable or temporarily invalid script metadata from
destructively clearing state.
- Returns an in-memory association when valid.
- Lazily reconstructs persisted environments after restart instead of
resolving every script during activation.
- Re-checks `requires-python` against the reconstructed Python version
before returning it.

**Safely rehydrates persisted associations**

- Requires an absolute executable path.
- Preserves associations while their cache entry is locked or being
created.
- Verifies that the executable exists and is a regular file.
- Resolves it into a `PythonEnvironment` and confirms that it belongs to
the expected extension-owned cache entry.
- Removes only definitively stale associations; transient filesystem or
resolver failures remain retryable.
- Emits a change event when a slow rehydration eventually succeeds,
including when the public API's initial one-second wait has already
elapsed.

**Validates warm in-memory associations**

- Periodically revalidates cached associations without performing full
resolution on every lookup.
- Detects executables deleted while VS Code remains open.
- Detects an environment rebuilt at the same cache path with a different
Python version.
- Preserves busy/locked entries instead of misclassifying them as stale.
- Coalesces simultaneous validations for the same script.
- Retains the existing environment object when resolution produces only
a new generated ID for the same Python, avoiding false changes and
duplicate ID-keyed resources.

**Protects persistence and selection from races**

- Serializes Memento read-modify-write operations so concurrent script
selections cannot lose one another.
- Uses per-script association revisions so an older rehydration cannot
overwrite a newer selection or unset.
- Removes stale persisted values conditionally, only if the inspected
path is still current.
- Keeps failed persistence writes from changing in-memory state or
emitting success-shaped events.
- Does not globally serialize unrelated environment operations.

**Updates central active-environment tracking**

- Keys inline-script selections by normalized script path rather than
containing project, so two scripts in one workspace can retain different
environments.
- Uses per-scope revisions and manager identity checks so slow refreshes
cannot overwrite newer selections.
- Ensures failed selections and failed refreshes do not discard a valid
in-flight refresh.
- Groups same-manager batch unsets and calls the manager once with the
complete URI array.
- Updates central cache entries and events only after the manager
operation succeeds.
- Attributes inline-script change events to the script URI rather than
the containing project URI.

### Example

Given two scripts in the same workspace:

```text
tools/report.py → Python 3.12 inline environment
tools/import.py → Python 3.13 inline environment
```

PR 7 stores and retrieves those associations independently. Selecting
the environment for `import.py` does not overwrite the last-known
environment for `report.py`.

After restart:

```text
get(report.py)
→ read persisted executable
→ verify cache ownership and current metadata
→ resolve environment
→ cache and return it
```

If `report.py` later changes from `requires-python = ">=3.11"` to
`">=3.13"`, its persisted Python 3.12 environment is no longer returned
as compatible.

### Persistence and failure semantics

| Condition | Behavior |
|---|---|
| Executable exists and cache ownership is valid | Rehydrate and return
|
| Cache entry is locked/in progress | Preserve association; retry later
|
| Resolver fails transiently | Preserve association; retry later |
| Executable is definitively missing and unlocked | Remove stale
association and notify |
| A newer selection wins during rehydration | Discard the stale result |
| Memento write fails | Keep previous in-memory/persisted selection and
propagate the error |

### Tests

Coverage includes:

- assign, retrieve, unset, and batch persistence;
- restart-time lazy rehydration and delayed success events;
- metadata compatibility changes;
- missing, malformed, unowned, busy, and transient cache states;
- warm deletion and same-path rebuild detection;
- concurrent persistence, rehydration, validation, selection, and unset
races;
- failed Memento writes;
- strict URI-scope validation;
- independent same-project script selections;
- stale and failed central refresh ordering;
- atomic same-manager batch unsets.

`npm run compile-tests`, `npm run lint`, the full unit suite, and the
focused persistence/central-manager suites are clean.

### Performance

- Rehydration is lazy rather than activation-blocking.
- Warm associations are cached and validation is throttled.
- Same-script rehydration and validation work is coalesced.
- Queues cover only shared persistence and mutation ordering; unrelated
script reads and environment-manager operations remain independent.

### User impact

**No default-path user impact yet.** This completes an internal Phase 2
manager capability. Automatic routing and user-facing entry points
arrive in later roadmap PRs.

Once routing is wired, script-specific selections will survive
extension-host restarts and remain independent even for multiple scripts
in the same workspace.

### Merge order

The core persistence behavior depends on the merged manager skeleton
(#1610). This branch is rebased on current `main`; PR 8 and PR 9 build
on this capability.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1a9f6ba1-9bd3-4664-bc25-a0d34d7a2e91
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature-request Request for new features or functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants